在前一篇中,我們讓使用者能夠新增飲食紀錄,但目前資料仍是存在記憶體中,一旦重新啟動 App,資料就會消失。
今天,我們要讓使用者能夠查看飲食紀錄清單頁,並且透過 SwiftData 將資料永久保存!
我們先將原本的 MealRecord 改寫為 SwiftData 的資料實體。
import Foundation
import SwiftData
@Model
class MealRecord {
    var id: UUID
    var name: String
    var calories: Int
    var category: MealCategory
    var date: Date
    
    init(
        id: UUID = UUID(),
        name: String,
        calories: Int,
        category: MealCategory,
        date: Date = .now
    ) {
        self.id = id
        self.name = name
        self.calories = calories
        self.category = category
        self.date = date
    }
}
並確保餐點分類 MealCategory 能被編碼與儲存:
enum MealCategory: String, Codable, CaseIterable {
    case breakfast = "早餐"
    case lunch = "午餐"
    case dinner = "晚餐"
    case snack = "點心"
}
接著我們調整Repository,為了取得SwiftData我們必須把ModelContext注入進來。並且因為是IO行為所以使用async/await來處理
接著我們要改寫MealRepository,讓它能透過SwiftData操作資料。由於存取資料屬於 I/O 行為,因此使用async/await進行非同步處理。
import SwiftData
import Foundation
class MealRepository: ObservableObject {
    static let shared = MealRepository()
    
    @Published private(set) var records: [MealRecord] = []
    
    private var modelContext: ModelContext?
    
    func configure(context: ModelContext) async{
        self.modelContext = context
        await fetchAll()
    }
    
    func addMeal(name: String, calories: Int, category: MealCategory) async{
        guard let context = modelContext else { return }
                
        let newMeal = MealRecord(
            name: name,
            calories: calories,
            category: category,
            date: .now
        )
        
        context.insert(newMeal)
                
        do {
            try context.save()
            await fetchAll() // 重新抓取資料
        } catch {
            print("無法儲存資料:\(error)")
        }
    }
    
    func fetchAll() async{
        guard let context = modelContext else { return }
            
        let descriptor = FetchDescriptor<MealRecord>(
            sortBy: [SortDescriptor(\.date, order: .reverse)]
        )
            
        do {
            records = try context.fetch(descriptor)
        } catch {
            print("無法讀取資料:\(error)")
        }
    }
    
    
    func delete(_ record: MealRecord) async{
        guard let context = modelContext else { return }
        context.delete(record)
        
        do {
            try context.save()
            await fetchAll()
        } catch {
            print("無法刪除資料:\(error)")
        }
    }
}
確保整個 App 都有 SwiftData 環境。
在App主程式中加上.modelContainer(for:)。
import SwiftUI
@main
struct ITHelpSideProjectApp: App {
    var body: some Scene {
        WindowGroup {
            DashboardView()
        }
        .modelContainer(for: MealRecord.self)
    }
}
ViewModel 將會負責串接 Repository 並提供給 UI 使用。
import SwiftUI
import SwiftData
class MealListViewModel: ObservableObject {
    @Published var records: [MealRecord] = []
    
    private let repository = MealRepository.shared
    
    init() {
        // 監聽 repository 的變化
        repository.$records
            .receive(on: RunLoop.main)
            .assign(to: &$records)
    }
    
    func configure(context: ModelContext) {
        Task {
            await repository.configure(context: context)
        }
    }
    
    /// 重新從資料庫載入
    func refresh() {
        Task {
            await repository.fetchAll()
        }
    }
    
    /// 新增一筆餐點紀錄
    func addMeal(name: String, calories: Int, category: MealCategory) {
        Task {
            await repository.addMeal(name: name, calories: calories, category: category)
        }
    }
    
    /// 刪除一筆紀錄
    func delete(at offsets: IndexSet) {
        for index in offsets {
            let record = records[index]
            Task {
                await repository.delete(record)
            }
        }
    }
}
import SwiftUI
import SwiftData
struct MealListView: View {
    @Environment(\.modelContext) private var modelContext
    @StateObject private var viewModel = MealListViewModel()
    
    var body: some View {
        VStack() {
            if (viewModel.records.isEmpty) {
                Text("尚未新增餐點")
                    .foregroundColor(.gray)
            } else {
                List {
                    ForEach(viewModel.records) { meal in
                        VStack(alignment: .leading, spacing: 6) {
                            Text(meal.name)
                                .font(.headline)
                            Text("\(meal.calories) 大卡")
                                .foregroundColor(.gray)
                            Text(meal.category.rawValue)
                                .font(.caption)
                                .foregroundColor(.secondary)
                        }
                        .padding(.vertical, 4)
                    }
                    .onDelete {indexSet in
                        viewModel.delete(at:indexSet)
                    }
                }
            }
        }.navigationTitle("飲食紀錄")
            .toolbar {
                NavigationLink(destination: AddMealView()) {
                    Image(systemName: "plus")
                }
            }
            .onAppear {
                viewModel.configure(context: modelContext)
            }
    }
    
}
#Preview {
    MealListView()
}
Section {
    NavigationLink(destination: MealListView()) {
        Label("查看所有飲食紀錄", systemImage: "list.bullet.rectangle")
            .font(.headline)
            .foregroundColor(.orange)
    }
    .frame(maxWidth: .infinity, alignment: .center)
}

完成後的清單頁能夠:
今天我們完成了飲食記錄 App 的清單功能